<?php
/**
 * @author Todd Burry <todd@vanillaforums.com>
 * @copyright 2009-2019 Vanilla Forums Inc.
 * @license GPL-2.0-only
 */

namespace Vanilla;

use Garden\Container\Container;
use Garden\EventManager;
use Vanilla\Contracts;
use Vanilla\Utility\ArrayUtils;
use Vanilla\Utility\CamelCaseScheme;
use Vanilla\Utility\DebugUtils;
use Vanilla\Utility\Deprecation;
use Vanilla\Utility\PhpClassParsed;

/**
 * Contains the information for a single addon.
 */
class Addon
{
    const TYPE_ADDON = "addon";
    const TYPE_LOCALE = "locale";
    const TYPE_THEME = "theme";

    const PATH_FULL = "full"; // full path
    const PATH_ADDON = "addon"; // path relative to PATH_ROOT
    const PATH_LOCAL = "local"; // path relative to the addon's subdirectory
    const PATH_REAL = "real"; // realpath()

    const PRIORITY_LOW = 10;
    const PRIORITY_NORMAL = 100;
    const PRIORITY_HIGH = 1000;

    const PRIORITY_THEME = Addon::PRIORITY_HIGH;
    const PRIORITY_PLUGIN = Addon::PRIORITY_NORMAL;
    const PRIORITY_LOCALE = 11;
    const PRIORITY_APPLICATION = Addon::PRIORITY_LOW;

    /**
     * @var array The addon's info array.
     */
    private $info = [];

    /**
     * @var array<string,PhpClassParsed> An array of classes.
     */
    private $classes = [];

    /**
     * @var string The root-relative directory of the addon.
     */
    private $subdir = "";

    /**
     * @var array An array of translation files indexed by locale.
     */
    private $translations = [];

    /**
     * @var array An array of special classes and files.
     */
    private $special = [];

    /** @var AddonSpecialClasses|null */
    private $specialClasses = null;

    /**
     * Addon constructor.
     *
     * @param string $subdir The root-relative subdirectory of the addon.
     */
    public function __construct($subdir = "")
    {
        if (!empty($subdir)) {
            $this->scan($subdir);
        }
    }

    /**
     * Scan a subdirectory and setup this addon.
     *
     * @param string $subdir The root-relative subdirectory to scan.
     */
    public function scan($subdir)
    {
        $this->setSubdir($subdir);

        // Look for the addon info.
        $info = $this->scanInfo();
        $this->setInfo($info);

        // Scan for classes.
        if ($this->getType() !== static::TYPE_LOCALE) {
            $classes = $this->scanClasses();
            $this->setClasses($classes);

            $this->specialClasses = AddonSpecialClasses::fromAddon($this);
        }

        // Scan for a structure file.
        if ($this->getType() === static::TYPE_ADDON || $this->getType() === static::TYPE_THEME) {
            if (file_exists($this->path("/settings/structure.php"))) {
                $this->special["structure"] = "/settings/structure.php";
            }

            if (file_exists($this->path("/settings/configuration.php"))) {
                $this->special["config"] = "/settings/configuration.php";
            }

            if (file_exists($this->path("/settings/bootstrap.php"))) {
                $this->special["bootstrap"] = "/settings/bootstrap.php";
            } elseif (file_exists($this->path("/bootstrap.php"))) {
                $this->special["bootstrap"] = "/bootstrap.php";
            }
        }

        // Scan for translations.
        $translations = $this->scanTranslations();
        $this->setTranslationPaths($translations);

        // Look for an icon.

        // Fix issues with the plugin that can be fixed.
        $this->check(true);
    }

    /**
     * Bind events from this addon.
     *
     * @param EventManager $eventManager
     */
    public function bindEvents(EventManager $eventManager)
    {
        foreach ($this->getEventHandlerClasses() as $eventHandlerClass) {
            // Include the plugin here, rather than wait for it to hit the autoloader. This way is much faster.
            include_once $this->getClassPath($eventHandlerClass);

            // bind the methods of the event handlers.
            $eventManager->bindClass($eventHandlerClass, $this->getPriority());
        }
    }

    /**
     * Bind events from this addon.
     *
     * @param EventManager $eventManager
     */
    public function unbindEvents(EventManager $eventManager)
    {
        foreach ($this->getEventHandlerClasses() as $eventHandlerClass) {
            $eventManager->unbindClass($eventHandlerClass);
        }
    }

    /**
     * Get the event handler classes for the addon.
     *
     * @return class-string[]
     */
    private function getEventHandlerClasses(): array
    {
        $specialClasses = $this->getSpecialClasses();
        $eventHandlerClasses = [];
        if ($specialClasses !== null) {
            $eventHandlerClasses = $specialClasses->getEventHandlersClasses();
        }
        if ($pluginClass = $this->getPluginClass()) {
            $eventHandlerClasses[] = $pluginClass;
        }
        return $eventHandlerClasses;
    }

    /**
     * Configure the container.
     *
     * @param Container $container The container.
     * @param 'new'|'old'|'both' $only
     */
    public function configureContainer(Container $container, string $only = "both")
    {
        if ($only === "both" || $only === "new") {
            $specialClasses = $this->getSpecialClasses();
            if ($specialClasses !== null) {
                foreach ($specialClasses->getContainerRulesClasses() as $containerRulesClass) {
                    /** @var AddonContainerRules $rules */
                    $rules = new $containerRulesClass();
                    if (DebugUtils::isTestMode()) {
                        $rules->configureTestContainer($container);
                    } else {
                        $rules->configureProductionContainer($container);
                    }
                }
            }
        }

        if ($only === "both" || $only === "old") {
            // Legacy bootstraps.
            if ($bootstrapPath = $this->getSpecial("bootstrap")) {
                $bootstrapPath = $this->path($bootstrapPath);
                include_once $bootstrapPath;
            }
        }
    }

    /**
     * Set the root-relative subdirectory of the addon.
     *
     * @param string $subdir The new subdirectory.
     * @return Addon Returns `$this` for fluent calls.
     */
    private function setSubdir($subdir)
    {
        $this->subdir = "/" . ltrim($subdir, "/\\");
        return $this;
    }

    /**
     * Get an addon's meta info.
     *
     * @return array|null Return the addon's info array or **null** if one could not be found.
     */
    private function scanInfo()
    {
        $subdir = $this->getSubdir();
        $dir = $this->path();

        // Look for an addon.json file.
        if (file_exists("$dir/addon.json")) {
            $addonJSON = file_get_contents("$dir/addon.json");
            if (!$addonJSON) {
                throw new \Exception("The addon at $subdir has an unreadable addon.json file.");
            }

            $info = json_decode($addonJSON, true);
            if (empty($info)) {
                throw new \Exception("The addon at $subdir has invalid JSON in addon.json.");
            }

            // Kludge that sets oldType until we unify applications and plugins into addon.
            [$addonParentFolder, $addonFolder] = explode("/", ltrim($subdir, "/"));
            if (in_array($addonParentFolder, ["applications", "plugins"])) {
                $info["oldType"] = substr($addonParentFolder, 0, -1);

                // Kludge that sets keyRaw until we use key everywhere.
                if ($info["oldType"] === "application") {
                    if (!isset($info["keyRaw"])) {
                        $info["keyRaw"] = $info["name"];
                    }
                } else {
                    if (isset($info["key"]) && $addonFolder !== $info["key"]) {
                        $info["keyRaw"] = $addonFolder;
                    }
                }
            }

            return $info;
        }

        // Make a list of info array paths to scan.
        $infoArrayPaths = array_merge($this->glob("/*plugin.php"), [
            "/default.php", // old plugin
            "/settings/about.php", // application
            "/about.php", // theme
            "/definitions.php", // locale
        ]);

        foreach ($infoArrayPaths as $path) {
            if ($info = $this->scanInfoArray($path)) {
                return $info;
            }
        }

        throw new \Exception("The addon at $subdir doesn't have any info.", 500);
    }

    /**
     * Get the subdir.
     *
     * @return string Returns the subdir.
     */
    public function getSubdir(): string
    {
        return $this->subdir;
    }

    /**
     * Generate a path for this addon relative to a given directory.
     *
     * @param string $subpath The sub-path to generate from.
     * @param string $relative One of the `self::PATH_*` constants.
     * @return string Returns a generate path.
     */
    public function path($subpath = "", $relative = self::PATH_FULL)
    {
        $subpath = $subpath ? "/" . ltrim($subpath, "\\/") : "";

        switch ($relative) {
            case self::PATH_FULL:
                return PATH_ROOT . $this->subdir . $subpath;
            case self::PATH_ADDON:
                return $this->subdir . $subpath;
            case self::PATH_REAL:
                return realpath(PATH_ROOT . $this->subdir . $subpath);
            case self::PATH_LOCAL:
            case null:
                return $subpath;
            default:
                throw new \InvalidArgumentException("Invalid path relation: $relative.", 500);
        }
    }

    /**
     * Perform a glob from this addon's subdirectory.
     *
     * @param string $pattern The pattern to glob.
     * @param bool $dirs Just directories.
     * @return array Returns an array of root-relative paths.
     * @see glob()
     */
    private function glob($pattern, $dirs = false)
    {
        $px = $this->path();
        $fullPattern = $px . $pattern;
        $strlen = strlen($px);
        $paths = glob($fullPattern, GLOB_NOSORT | ($dirs ? GLOB_ONLYDIR : 0));
        if (!is_array($paths)) {
            return [];
        }
        foreach ($paths as &$path) {
            $path = substr($path, $strlen);
        }
        return $paths;
    }

    /**
     * Scan an addon's info array.
     *
     * @param string $subpath The addon-relative path to the PHP file containing the info array.
     * @return array|null Returns the info array or **null** if there isn't one.
     */
    private function scanInfoArray($subpath)
    {
        $path = $this->path($subpath);
        if (!file_exists($path)) {
            return null;
        }

        // Extract the lines of the file that contain the info array.
        $lines = file($path);
        $infoString = "";
        $infoVarFound = false;

        foreach ($lines as $line) {
            if ($infoVarFound) {
                $infoString .= $line;
                if (substr(trim($line), -1) === ";") {
                    break;
                }
            } elseif (preg_match('`^\s*\$(Plugin|Application|Theme|Locale)Info\[`', $line)) {
                $infoVarFound = true;
                $infoString .= $line;
            }
        }
        if ($infoString != "") {
            try {
                eval($infoString);
            } catch (\Throwable $ex) {
                trigger_error("Error scanning info array in $path. " . $ex->getMessage(), E_USER_WARNING);
                return null;
            } catch (\Exception $ex) {
                trigger_error("Error scanning info array in $path. " . $ex->getMessage(), E_USER_WARNING);
                return null;
            }
        } else {
            return null;
        }

        $oldType = null;

        // See which info array is defined.
        /** @psalm-suppress UndefinedVariable */
        if (!empty($PluginInfo) && is_array($PluginInfo)) {
            $array = $PluginInfo;
            $type = static::TYPE_ADDON;
            $priority = static::PRIORITY_PLUGIN;
            $oldType = "plugin";
        } elseif (!empty($ApplicationInfo) && is_array($ApplicationInfo)) {
            $array = $ApplicationInfo;
            $type = static::TYPE_ADDON;
            $priority = static::PRIORITY_APPLICATION;
            $oldType = "application";
        } elseif (!empty($ThemeInfo) && is_array($ThemeInfo)) {
            $array = $ThemeInfo;
            $type = static::TYPE_THEME;
            $priority = static::PRIORITY_THEME;
        } elseif (!empty($LocaleInfo) && is_array($LocaleInfo)) {
            $array = $LocaleInfo;
            $type = static::TYPE_LOCALE;
            $priority = static::PRIORITY_LOCALE;
        } else {
            return null;
        }

        $oldInfo = reset($array);
        $key = key($array);

        // Convert the info array to the new syntax.
        $nameScheme = new CamelCaseScheme();
        $info = $nameScheme->convertArrayKeys($oldInfo);
        if (isset($oldInfo["RegisterPermissions"])) {
            $info["registerPermissions"] = $oldInfo["RegisterPermissions"];
        }

        $info["key"] = $key;
        $info["type"] = $type;

        $oldInfo = reset($array);
        $key = key($array);

        if (empty($info["priority"])) {
            $info["priority"] = $priority;
        }

        if (isset($oldType)) {
            $info["oldType"] = $oldType;

            if ($oldType === "application" && empty($info["name"])) {
                $info["name"] = $key;
            }
        }

        // Convert the author.
        if (!empty($info["author"])) {
            $author["name"] = $info["author"];
            unset($info["author"]);
        }
        if (!empty($info["authorEmail"])) {
            $author["email"] = $info["authorEmail"];
            unset($info["authorEmail"]);
        }
        if (!empty($info["authorUrl"])) {
            $author["homepage"] = $info["authorUrl"];
            unset($info["authorUrl"]);
        }

        if (!empty($author)) {
            $authors = $this->splitAuthors($author);
            if (empty($info["authors"]) || !is_array($info["authors"])) {
                $info["authors"] = $authors;
            } else {
                $info["authors"] = array_merge($info["authors"], $authors);
            }
        }

        // Convert the requires.
        $require = $this->convertRequire($oldInfo, ["RequiredPlugins", "RequiredApplications"]);
        if (!empty($require)) {
            $info["require"] = $require;
        }
        unset($info["requiredPlugins"], $info["requiredApplications"]);

        return $info;
    }

    /**
     * Split an author array that may contain multiple authors separated by commas.
     *
     * This method handles the older plugin info arrays.
     *
     * @param array $author The author array to split.
     * @return array[array[string]] Returns the authors array.
     */
    private function splitAuthors($author)
    {
        $authors = [];

        foreach ($author as $key => $value) {
            $parts = explode(",", $value);

            foreach ($parts as $i => $part) {
                $authors[$i][$key] = trim($part);
            }
        }

        return $authors;
    }

    /**
     * Convert the info array style requirements to the composer-like require format.
     *
     * @param array $info The addon info array.
     * @param array $keys The old requirement arrays.
     */
    private static function convertRequire(array $info, array $keys)
    {
        $require = [];

        foreach ($keys as $key) {
            if (empty($info[$key]) || !is_array($info[$key])) {
                continue;
            }

            foreach ($info[$key] as $addonKey => $version) {
                if (!preg_match("`^[<>]=?|!=|~|\^`", $version)) {
                    $version = ">=" . $version;
                }
                $require[strtolower($addonKey)] = $version;
            }
        }

        return $require;
    }

    /**
     * Set the info.
     *
     * @param array $info The new info array to set.
     * @return Addon Returns `$this` for fluent calls.
     */
    private function setInfo(array $info)
    {
        $this->info = $info;
        return $this;
    }

    /**
     * Get the type of addon.
     *
     * @return string Returns one of the **Addon::TYPE_*** constants.
     */
    public function getType()
    {
        return empty($this->info["type"]) ? "" : $this->info["type"];
    }

    /**
     * Scan for all the classes in this addon.
     *
     * @return PhpClassParsed[] Returns an array of subpaths.
     */
    private function scanClasses(): array
    {
        $paths = $this->scanClassPaths();

        $parsedFiles = [];
        foreach ($paths as $path) {
            $classNames = static::scanFile($this->path($path));
            foreach ($classNames as $className) {
                if (empty($className)) {
                    continue;
                }
                $loweredClassName = strtolower($className);
                if (str_ends_with($loweredClassName, "plugin") || str_ends_with($loweredClassName, "hooks")) {
                    if (empty($this->special["plugin"])) {
                        $this->special["plugin"] = $className;
                    } else {
                        $this->special["otherPlugins"][] = $className;
                    }
                }
                $parsedFile = new PhpClassParsed($className, $this->path($path), $this->getKey());
                $parsedFiles[$loweredClassName] = $parsedFile;
            }
        }
        return $parsedFiles;
    }

    /**
     * Scan the addon for potential class paths.
     *
     * @return \Traversable Returns a list of paths to PHP files.
     */
    private function scanClassPaths()
    {
        $dirsToScan = ["", "/controllers", "/library", "/src", "/models", "/modules", "/settings/class.hooks.php"];

        // Get all the uppercase top level directories (likely namespaces)
        // and add them to the list.
        $rootDir = $this->path("", Addon::PATH_FULL);
        $subDirs = glob($rootDir . "/*", GLOB_ONLYDIR);
        foreach ($subDirs as $subDir) {
            $trimmedDir = basename($subDir);
            $isUppercaseDirName = strlen($trimmedDir) > 0 && ctype_upper($trimmedDir[0]);
            if ($isUppercaseDirName) {
                $dirsToScan[] = "/" . $trimmedDir;
            }
        }

        foreach ($dirsToScan as $dir) {
            foreach ($this->scanDirPhp($dir) as $path) {
                yield $path;
            }
        }
    }

    /**
     * Recursively scan a directory for PHP files.
     *
     * @param string $dir The path to the directory to scan.
     * @return \Traversable Returns a list of paths to PHP files.
     */
    private function scanDirPhp($dir)
    {
        if (substr($dir, -4) === ".php") {
            if (file_exists($this->path($dir, Addon::PATH_FULL))) {
                yield $dir;
            }
            return;
        }

        // Get the php files in the directory.
        foreach ($this->glob("$dir/*.php") as $path) {
            yield $path;
        }

        // Don't recursively scan the root of an addon.
        if (empty($dir)) {
            return;
        }

        // Get all of the php files from subdirectories.
        foreach ($this->glob("$dir/*", true) as $subdir) {
            foreach ($this->scanDirPhp($subdir) as $path) {
                yield $path;
            }
        }
    }

    /**
     * Looks what classes and namespaces are defined in a file and returns them.
     *
     * @param string $path Path to file.
     * @return string[] Returns an empty array if no classes are found or a list of fully qualified classes in the file.
     * @see http://stackoverflow.com/a/11114724/1984219
     */
    private static function scanFile($path): array
    {
        $classes = $nsPos = $final = [];
        $foundNamespace = false;
        $ii = 0;

        if (!file_exists($path)) {
            return [];
        }

        $er = error_reporting();
        error_reporting(E_ALL ^ E_NOTICE);

        $php_code = file_get_contents($path);
        $tokens = token_get_all($php_code);
        //        $count = count($tokens);

        foreach ($tokens as $i => $token) {
            //} ($i = 0; $i < $count; $i++) {
            if (!$foundNamespace && $token[0] == T_NAMESPACE) {
                $nsPos[$ii]["start"] = $i;
                $foundNamespace = true;
            } elseif ($foundNamespace && ($token == ";" || $token == "{")) {
                $nsPos[$ii]["end"] = $i;
                $ii++;
                $foundNamespace = false;
            } elseif (
                $i - 2 >= 0 &&
                $tokens[$i - 2][0] == T_CLASS &&
                $tokens[$i - 1][0] == T_WHITESPACE &&
                $token[0] == T_STRING
            ) {
                if ($i - 4 >= 0 && $tokens[$i - 4][0] == T_ABSTRACT) {
                    $classes[$ii][] = ["name" => $token[1], "type" => "ABSTRACT CLASS"];
                } else {
                    $classes[$ii][] = ["name" => $token[1], "type" => "CLASS"];
                }
            } elseif (
                $i - 2 >= 0 &&
                $tokens[$i - 2][0] == T_INTERFACE &&
                $tokens[$i - 1][0] == T_WHITESPACE &&
                $token[0] == T_STRING
            ) {
                $classes[$ii][] = ["name" => $token[1], "type" => "INTERFACE"];
            } elseif (
                $i - 2 >= 0 &&
                $tokens[$i - 2][0] == T_TRAIT &&
                $tokens[$i - 1][0] == T_WHITESPACE &&
                $token[0] == T_STRING
            ) {
                $classes[$ii][] = ["name" => $token[1], "type" => "TRAIT"];
            }
        }
        error_reporting($er);
        if (empty($classes)) {
            return [];
        }

        if (!empty($nsPos)) {
            foreach ($nsPos as $k => $p) {
                $ns = "";
                for ($i = $p["start"] + 1; $i < $p["end"]; $i++) {
                    $ns .= $tokens[$i][1];
                }

                $ns = trim($ns);
                if (!empty($classes[$k + 1])) {
                    $final[$k] = ["namespace" => $ns, "classes" => $classes[$k + 1]];
                }
            }
            $classes = $final;
        }

        // Wrap this all up into parsed php files.
        $result = [];
        foreach ($classes as $classNsPairing) {
            // We have namespaced stuff.
            foreach ($classNsPairing["classes"] ?? $classNsPairing as $className) {
                $className = $className["name"] ?? null;
                if ($className === null) {
                    continue;
                }
                if (!empty($classNsPairing["namespace"])) {
                    $className = $classNsPairing["namespace"] . "\\" . $className;
                }
                $result[] = $className;
            }
        }

        return $result;
    }

    /**
     * Set the classes.
     *
     * @param PhpClassParsed[] $classes
     * @return Addon Returns `$this` for fluent calls.
     */
    private function setClasses($classes)
    {
        $this->classes = $classes;
        return $this;
    }

    /**
     * Scan the addon for translation files.
     */
    private function scanTranslations()
    {
        $result = [];

        if ($this->getType() === static::TYPE_LOCALE) {
            // Locale files are a little different. Their translations are in the root.
            $locale = self::canonicalizeLocale($this->getInfoValue("locale", "en"));
            $result[$locale] = $this->glob("/*.php");
        } else {
            // Look for individual locale files.
            $localePaths = $this->glob("/locale/*.php");
            foreach ($localePaths as $localePath) {
                $locale = self::canonicalizeLocale(basename($localePath, ".php"));
                $result[$locale][] = $localePath;
            }

            // Look for locale files in a directory. This scan method is deprecated, but still supported.
            $localePaths = $this->glob("/locale/*/definitions.php");
            foreach ($localePaths as $localePath) {
                $locale = self::canonicalizeLocale(basename(dirname($localePath)));
                $result[$locale][] = $localePath;

                $properPath = $this->path("/locale/$locale.php", self::PATH_ADDON);
                trigger_error("Locales in $localePath is deprecated. Use $properPath instead.", E_USER_DEPRECATED);
            }
        }

        return $result;
    }

    /**
     * Canonicalize a locale string so different representations of the same locale can be used together.
     *
     * @param string $locale The locale code to canonicalize.
     * @return string Returns the canonicalized version of the locale code.
     */
    private static function canonicalizeLocale($locale)
    {
        $locale = str_replace(["-", "@"], ["_", "__"], $locale);
        $parts = explode("_", $locale, 2);
        if (isset($parts[1])) {
            $parts[1] = strtoupper($parts[1]);
        }
        $result = implode("_", $parts);
        // Remove everything from the string except letters, numbers, dashes, and underscores.
        $result = preg_replace("/([^\w-])/", "", $result);

        // This is a bit of a kludge, but we are deprecating en_CA in favour of just en.
        if ($result === "en_CA") {
            $result = "en";
        }

        return $result;
    }

    /**
     * @inheritdoc
     */
    public function getInfoValue(string $key, $default = null)
    {
        return isset($this->info[$key]) ? $this->info[$key] : $default;
    }

    /**
     * Set the translation paths.
     *
     * @param array $translations The new translation paths.
     * @return Addon Returns `$this` for fluent calls.
     */
    private function setTranslationPaths($translations)
    {
        $this->translations = $translations;
        return $this;
    }

    /**
     * Check the addon for data issues.
     *
     * @param bool $trigger Whether or not to trigger a notice if there are issues.
     * @return array Returns an array of issues with the addon.
     */
    public function check($trigger = false)
    {
        $issues = [];
        if (!isset($this->info["Issues"])) {
            $this->info["Issues"] = &$issues;
        }

        $rawKey = $this->getKey();
        $subdir = basename($this->getSubdir());

        // Check for missing fields.
        $required = ["key", "type"];
        foreach ($required as $fieldName) {
            if (empty($this->info[$fieldName])) {
                $issues["required-$fieldName"] = "The $fieldName info field is required.";
            }
        }

        // Make sure the addon has a correct type.
        if ($this->getType()) {
            if (!in_array($this->getType(), [static::TYPE_ADDON, static::TYPE_THEME, static::TYPE_LOCALE])) {
                $type = $this->getType();
                $issues["type-invalid"] = "The addon has an invalid type ($type).";
            } elseif (empty($this->info["priority"])) {
                // Add a missing priority.
                $priorities = [
                    static::TYPE_ADDON => static::PRIORITY_NORMAL,
                    static::TYPE_LOCALE => static::PRIORITY_LOCALE,
                    static::TYPE_THEME => static::PRIORITY_THEME,
                ];
                $this->info["priority"] = $priorities[$this->getType()];
            }
        }

        // Themes and locales must have a key that matches their subdirectories.
        if ($rawKey !== $subdir && in_array($this->getType(), [static::TYPE_LOCALE, static::TYPE_THEME])) {
            $issues["key-subdir-mismatch"] = "The addon key must match its subdirectory name ($rawKey vs. $subdir).";
        }

        if ($this->getType() === static::TYPE_ADDON) {
            // Lowercase the keys of the other types.
            $key = strtolower($rawKey);
            if ($key !== $rawKey) {
                $this->info["key"] = $key;
                $this->info["keyRaw"] = $rawKey;
            }

            if (strcasecmp($key, basename($this->getSubdir())) !== 0) {
                $issues[
                    "key-subdir-mismatch-case"
                ] = "The addon key must match its subdirectory name ($key vs. $subdir).";
            }
        }

        if (preg_match('`-(addon|theme|locale)$`', $rawKey)) {
            $issues["invalid-key-suffix"] = "The addon key cannot end with -addon, -theme, or -locale.";
        }

        if (!empty($this->special["otherPlugins"])) {
            $plugins = implode(", ", array_merge([$this->special["plugin"]], $this->special["otherPlugins"]));
            $issues["multiple-plugins"] = "The addon should have at most one plugin class ($plugins).";
        }

        if (isset($this->info["require"]) && !is_array($this->info["require"])) {
            $issues["invalid-require"] = "The require key must be an array.";
        }

        if (isset($this->info["conflict"]) && !is_array($this->info["conflict"])) {
            $issues["invalid-conflict"] = "The conflict key must be an array.";
        }

        if ($trigger) {
            $this->triggerIssues();
        }

        return $issues;
    }

    /**
     * Trigger the plugin's issues
     *
     * @return Addon Returns $this for fluent calls.
     */
    protected function triggerIssues()
    {
        $issues = val("Issues", $this->info, []);
        if ($count = count($issues)) {
            $subdir = $this->getSubdir();

            trigger_error("The addon in $subdir has $count issue(s)." . implode(", ", $issues), E_USER_NOTICE);
            foreach ($issues as $issue) {
                trigger_error($issue, E_USER_NOTICE);
            }
        }

        return $this;
    }

    /**
     * Get this addon's key.
     *
     * @return string Returns the key as a string.
     */
    public function getKey(): string
    {
        return empty($this->info["key"]) ? "" : $this->info["key"];
    }

    /**
     * Get the global key of an addon.
     *
     * This method allows addons of all types to be keyed in a global namespace.
     *
     * Addons of type "addon" use their key as their global key. All other types have the "-<type>" suffix.
     *
     * @return string Returns a string key.
     */
    public function getGlobalKey(): string
    {
        if ($this->getType() === Addon::TYPE_ADDON) {
            return $this->getKey();
        } else {
            return $this->getKey() . "-" . $this->getType();
        }
    }

    /**
     * Split a global key into an addon key and type.
     *
     * @param string $key They key to split.
     * @return string[2] Returns an array in the form [key, type].
     */
    public static function splitGlobalKey(string $key): array
    {
        if (preg_match('`^(.+)-(locale|theme)$`', $key, $m)) {
            return [$m[1], $m[2]];
        } else {
            return [$key, Addon::TYPE_ADDON];
        }
    }

    /**
     * Support {@link var_export()} for caching.
     *
     * @param array $array The array to load.
     * @return Addon Returns a new addon with the properties from {@link $array}.
     */
    public static function __set_state(array $array)
    {
        $array += ["subdir" => "", "info" => [], "classes" => [], "translations" => []];

        $addon = new Addon();
        $addon
            ->setSubdir($array["subdir"])
            ->setInfo($array["info"])
            ->setClasses($array["classes"])
            ->setTranslationPaths($array["translations"])
            ->setSpecialArray(empty($array["special"]) ? [] : $array["special"])
            ->triggerIssues();

        $addon->specialClasses = $array["specialClasses"] ?? AddonSpecialClasses::__set_state([]);

        return $addon;
    }

    /**
     * Set the special array.
     *
     * @param array $special The new special array.
     * @return Addon Returns $this for fluent calls.
     */
    private function setSpecialArray(array $special)
    {
        $this->special = $special;
        return $this;
    }

    /**
     * Compare two addon's by priority so that they can be sorted.
     *
     * @param Addon $a The first addon to compare.
     * @param Addon $b The second addon to compare.
     * @return int Returns -1, 0, or 1.
     */
    public static function comparePriority(Addon $a, Addon $b)
    {
        if ($a->getPriority() > $b->getPriority()) {
            return -1;
        } elseif ($a->getPriority() < $b->getPriority()) {
            return 1;
        } else {
            return 0;
        }
    }

    /**
     * Get the priority of this addon.
     *
     * An addon's priority determines the order of things like translations, autoloading, and event firing.
     * Addons with higher priorities will generally override addons with lower priority.
     *
     * @return int Returns the priority.
     */
    public function getPriority()
    {
        return (int) $this->getInfoValue("priority", Addon::PRIORITY_NORMAL);
    }

    /**
     * Check a version against a version requirement.
     *
     * @param string $version The version to check.
     * @param string $requirement The version requirement.
     * @return bool Returns **true** if the version checks out or **false** otherwise.
     */
    public static function checkVersion($version, $requirement)
    {
        // Split the version up on operator boundaries.
        $final = self::splitRequirement($requirement);

        $valid = self::testRequirement($version, $final);
        return $valid;
    }

    /**
     * Split a requirements string into comparisons.
     *
     * @param string $requirement The requirement to split.
     * @return array
     */
    private static function splitRequirement($requirement)
    {
        $parts = preg_split(
            "`( - |\s*>=\s*|\s*<=\s*|\s*>\s*|\s*<\s*|\s*!=\s*|\s*,\s*|\|\|| )`",
            $requirement,
            -1,
            PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY
        );
        $working = [];
        $j = -1;
        foreach ($parts as $i => $part) {
            if ($part !== " ") {
                $part = trim($part);
            }

            switch ($part) {
                case ">":
                case "<":
                case ">=":
                case "<=":
                case "!=":
                    $j = count($working);
                    $working[$j] = ["op" => $part];
                    break;
                case "-":
                    // The last version can't have an operator already.
                    if (!empty($working[$j]["v"]) && empty($working[$j]["op"])) {
                        $working[$j]["op"] = $part;
                    }
                    break;
                case ",":
                case " ":
                case "||":
                    $logic = $part === "||" ? "or" : "and";
                    if (!empty($working[$j]["v"])) {
                        $working[$j]["logic"] = $logic;
                    }
                    break;
                default:
                    // This is a version.
                    if (isset($working[$j]["op"]) && $working[$j]["op"] === "-") {
                        $working[$j]["v2"] = $part;
                    } elseif (!isset($working[$j]) || !empty($working[$j]["v"])) {
                        $j = count($working);
                        $working[$j]["v"] = $part;
                    } else {
                        $working[$j]["v"] = $part;
                    }
            }
        }

        $andGroups = [];
        $andStart = 0;
        foreach ($working as $i => $row) {
            $logic = isset($row["logic"]) ? $row["logic"] : "and";

            if ($logic === "or") {
                // Split off the last and group.
                $andGroups[] = array_slice($working, $andStart, $i - $andStart + 1);
                $andStart = $i + 1;
            }
        }
        if ($andStart <= count($working)) {
            $andGroups[] = array_slice($working, $andStart);
        }

        if (count($andGroups) === 1) {
            $andGroup = reset($andGroups);
            if (count($andGroup) == 1) {
                $final = reset($andGroup);
            } else {
                $final = ["and", $andGroup];
            }
        } else {
            $items = [];
            foreach ($andGroups as $andGroup) {
                if (count($andGroup) === 1) {
                    $items[] = reset($andGroup);
                } else {
                    $items[] = ["and", $andGroup];
                }
            }

            $final = ["or", $items];
        }

        return $final;
    }

    /**
     * Test an individual requirement.
     *
     * Requirements are arrays in the following form:
     *
     * - `['and', [requirements]]`: All requirements mast be valid.
     * - `['or', [requirements]]`: One of the requirements must be valid.
     * - `['op' => '<comparison>', 'v' => '<version>']`: An operator and version to compare.
     * - `['op' => '-', 'v' => '<version>', 'v2' => '<version>']`: Compare a range of versions.
     *
     * @param string $version The version to test.
     * @param array $req The requirement to test.
     * @return bool Returns **true** if the test passes or **false** otherwise.
     */
    private static function testRequirement($version, $req)
    {
        if (isset($req[0])) {
            // This is a boolean group.
            $logic = $req[0];
            foreach ($req[1] as $part) {
                $valid = self::testRequirement($version, $part);

                if ($valid && $logic === "or") {
                    return true;
                } elseif (!$valid && $logic === "and") {
                    return false;
                }
            }

            return $logic === "or" ? false : true;
        } else {
            // This is an individual requirement.
            $req += ["op" => "==", "v" => "0.0", "logic" => ",", "v2" => "999999"];
            $op = $req["op"];

            if ($req["v"] === "*") {
                $valid = true;
            } elseif ($op === "-") {
                $valid = version_compare($version, $req["v"], ">=") && version_compare($version, $req["v2"], "<=");
            } else {
                $valid = version_compare($version, $req["v"], $op);
            }
            return $valid;
        }
    }

    /**
     * Return a function that can be used as a callback to filter arrays of {@link Addon} objects.
     *
     * @param array $where A where array that filters the info array.
     * @return \Closure Returns a new closure.
     */
    public static function makeFilterCallback($where)
    {
        return function (Addon $addon) use ($where) {
            foreach ($where as $key => $value) {
                if ($key === "oldType") {
                    $valid = isset($addon->info["oldType"]) && $addon->info["oldType"] === $value;
                } elseif ($value === null) {
                    $valid = !isset($addon->info[$key]);
                } else {
                    $valid = $addon->getInfoValue($key) == $value;
                }
                if (!$valid) {
                    return false;
                }
            }
            return true;
        };
    }

    /**
     * Get the version number of the addon.
     *
     * This is just a convenience method for grabbing the version number from the info array.
     *
     * @return string Returns a version number or an empty string if there isn't one.
     */
    public function getVersion()
    {
        return (string) $this->getInfoValue("version", "");
    }

    /**
     * Get this addon's human-readable name.
     *
     * @return string Returns the name of the addon or its key if it has no name.
     */
    public function getName()
    {
        $displayName = $this->getInfoValue("displayName");
        $name = $this->getInfoValue("name");
        $rawKey = $this->getRawKey();

        return $displayName ?? ($name ?? $rawKey);
    }

    /**
     * Get this addon's raw case-sensitive key.
     *
     * Addon's have a lowercase key, but some places still require the uppercase one.
     *
     * @return string Returns the key as a string.
     */
    public function getRawKey()
    {
        return $this->getInfoValue("keyRaw", $this->getKey());
    }

    /**
     * Get the required addons for this addon.
     *
     * @return array Returns an array in the form addonKey => version.
     */
    public function getRequirements()
    {
        $result = $this->getInfoValue("require", []);
        if (!is_array($result)) {
            return [];
        }
        return $result;
    }

    /**
     * Get addons that conflict with this addon.
     *
     * @return array Returns an array in the form addonKey => version.
     */
    public function getConflicts()
    {
        $result = $this->getInfoValue("conflict", []);
        if (!is_array($result)) {
            return [];
        }
        return $result;
    }

    /**
     * @inheritdoc
     */
    public function getInfo(): array
    {
        return $this->info;
    }

    /**
     * Get the classes.
     *
     * @return PhpClassParsed[] Returns the classes.
     */
    public function getClasses(): array
    {
        return $this->classes;
    }

    /**
     * @return AddonSpecialClasses|null
     */
    public function getSpecialClasses(): ?AddonSpecialClasses
    {
        return $this->specialClasses;
    }

    /**
     * Do a very basic test of this addon.
     *
     * The test includes some of the files on this addon which will throw an exception if there are any major issues.
     *
     * @param bool $throw Whether or not to throw an exception.
     * @return bool Returns **true** if the addon was successfully tested or **false** otherwise.
     */
    public function test($throw = true)
    {
        try {
            // Include the plugin file.
            if ($className = $this->getPluginClass()) {
                $classFile = $this->classes[strtolower($className)] ?? null;
                if ($classFile !== null) {
                    include_once $classFile->getFilePath();
                }
            }

            // Include the configuration file.
            if ($configPath = $this->getSpecial("config")) {
                include $this->path($configPath);
            }

            // Include locale files.
            foreach ($this->getTranslationPaths() as $paths) {
                foreach ($paths as $path) {
                    include $this->path($path);
                }
            }
            return true;
        } catch (\Throwable $ex) {
            // PHP 7 can trap more errors, so cast it into a PHP 5.x compatible exception.
            $ex2 = new \Exception($ex->getMessage(), $ex->getCode());
        } catch (\Exception $ex) {
            $ex2 = $ex;
        }

        if ($throw) {
            throw $ex2;
        } else {
            return false;
        }
    }

    /**
     * Get the name of the plugin class for this addon, if any.
     *
     * @return string Returns the fully qualified name of the class or an empty string if it doesn't have one.
     */
    public function getPluginClass()
    {
        return isset($this->special["plugin"]) ? $this->special["plugin"] : "";
    }

    /**
     * Get an item from the special array.
     *
     * @param string $key The key in the special array.
     * @param mixed $default The default if the key isn't set.
     * @return mixed Returns the special item or {@link $default}.
     */
    public function getSpecial($key, $default = null)
    {
        return isset($this->special[$key]) ? $this->special[$key] : $default;
    }

    /**
     * Get translation paths.
     *
     * @param string $locale If passed then only the translation paths for this locale will be returned.
     * @return array Returns an array of translation paths or an array of locale codes pointing to translation paths.
     */
    public function getTranslationPaths($locale = "")
    {
        if (empty($locale)) {
            return $this->translations;
        } else {
            $safeLocale = self::canonicalizeLocale($locale);
            return isset($this->translations[$safeLocale]) ? $this->translations[$safeLocale] : [];
        }
    }

    /**
     * Get the path of a class within this addon.
     *
     * This is a case insensitive lookup.
     *
     * @param string $fullClassName Fully qualified class name.
     *
     * @return string Returns the path or an empty string of the class isn't found.
     */
    public function getClassPath(string $fullClassName)
    {
        $phpFile = $this->classes[strtolower($fullClassName)] ?? null;
        if ($phpFile === null) {
            return "";
        } else {
            return $phpFile->getFilePath();
        }
    }

    /**
     * Get the path to the icon for this addon.
     *
     * @param string $relative One of the **Addon::PATH_*** constants.
     * @return string Returns the path of the icon relative to {@link $relative} or an empty string if there is no icon.
     */
    public function getIcon($relative = self::PATH_ADDON)
    {
        if ($icon = $this->getInfoValue("icon")) {
            return $this->path("/" . ltrim($icon, "\\/"), $relative);
        } else {
            $files = ["icon.png", "screenshot.png", "mobile.png"];
            foreach ($files as $file) {
                if (file_exists($this->path($file))) {
                    return $this->path($file, $relative);
                }
            }
        }
        return "";
    }

    /**
     * Parse a fully qualified class name and return the namespace and className of it.
     *
     * @param string $fullClassName Fully qualified class name.
     * @return array ['namespace' => $namespace, 'className' => $className]
     *
     * @deprecated Just lookup the class directly.
     */
    public static function parseFullyQualifiedClass($fullClassName)
    {
        Deprecation::log();
        $lastNamespaceSeparatorPos = strrpos($fullClassName, "\\");
        if ($lastNamespaceSeparatorPos === false) {
            $namespace = "";
            $className = $fullClassName;
        } else {
            $namespace = substr($fullClassName, 0, $lastNamespaceSeparatorPos + 1);
            $className = substr($fullClassName, $lastNamespaceSeparatorPos + 1);
        }

        return [
            "namespace" => $namespace,
            "className" => $className,
        ];
    }
}
